Implementing MVVM with SwiftUI and UIKit
Course: iOS App Development
Topic: Implementing the Model-View-ViewModel (MVVM) Architecture with SwiftUI and UIKit
Course Link: iOS App Development Training at JustAcademy
Demo Link: Register for a Course Demo
Website: JustAcademy
1. Introduction to MVVM Architecture
MVVM stands for Model-View-ViewModel. It is a software architectural pattern that separates an application's data and business logic from its user interface. MVVM helps developers create iOS applications that are easier to maintain, test, extend, and debug.
In iOS development, MVVM can be used with both SwiftUI and UIKit. SwiftUI provides built-in mechanisms for observing state and updating views, while UIKit commonly uses property observers, closures, Combine, delegates, or other communication techniques to connect the ViewModel with the interface.
Without a clear architecture, a view controller or SwiftUI view can become responsible for networking, data processing, validation, navigation, and UI updates. This creates tightly coupled code that becomes difficult to maintain as an application grows. MVVM distributes these responsibilities into distinct components.
Learning Objectives
- Understand the Model, View, and ViewModel responsibilities.
- Learn how data moves through an MVVM-based application.
- Implement MVVM using SwiftUI and observable state.
- Implement MVVM with UIKit and connect the ViewModel to view controllers.
- Handle loading states, errors, user input, and asynchronous requests.
- Understand dependency injection, testability, and common architectural mistakes.
2. What Are the Three Components of MVVM?
2.1 Model
The Model represents the application's data and domain concepts. It may contain structures, classes, enumerations, validation rules, and domain-specific operations. Models should generally not depend on SwiftUI views or UIKit view controllers.
import Foundation
struct User: Identifiable, Codable {
let id: Int
let name: String
let email: String
}
In this example, User describes a user record. It does not decide how that record is displayed on the screen.
2.2 View
The View displays information and receives user interactions. In SwiftUI, the View is typically a structure conforming to View. In UIKit, it can consist of a UIViewController, UIView, labels, buttons, tables, and collection views.
The View should focus on presentation. It asks the ViewModel to perform an action and reflects the state exposed by that ViewModel.
2.3 ViewModel
The ViewModel connects the Model to the View. It prepares data for presentation, exposes observable state, processes user actions, coordinates application operations, and manages presentation-related logic such as loading and error messages.
A ViewModel should generally avoid directly manipulating UIKit views or constructing SwiftUI view hierarchies. Keeping it independent of the UI framework makes it easier to test.
3. How MVVM Works
- The user interacts with the View, such as tapping a button or entering a search query.
- The View calls an action or method on the ViewModel.
- The ViewModel validates the input and performs the required operation.
- The Model or a service provides data or performs a domain operation.
- The ViewModel updates its observable state.
- The View observes the changed state and refreshes the interface.
For example, when a user taps “Load Users,” the View calls viewModel.loadUsers(). The ViewModel requests data from a service, stores the result, and updates its loading state. The View then displays the user list or an error message.
4. MVVM Compared with Other Responsibilities
| Component |
Main Responsibility |
Example |
| Model |
Represents application data |
User, Product, Order |
| View |
Displays state and captures interactions |
UserListView, UIViewController |
| ViewModel |
Transforms data into presentation state and handles UI actions |
UserListViewModel |
| Service or Repository |
Retrieves or persists data |
UserService, UserRepository |
A service or repository is not one of the three core MVVM components, but adding one is often helpful. It prevents the ViewModel from becoming responsible for low-level networking, database access, or storage details.
5. Implementing MVVM with SwiftUI
SwiftUI uses a declarative UI approach. The interface describes what should appear for a given state, and SwiftUI updates the interface when observed state changes.
5.1 Create the Model
import Foundation
struct TaskItem: Identifiable {
let id: UUID
var title: String
var isCompleted: Bool
init(
id: UUID = UUID(),
title: String,
isCompleted: Bool = false
) {
self.id = id
self.title = title
self.isCompleted = isCompleted
}
}
The TaskItem structure represents one task. Its identifier allows SwiftUI to distinguish tasks when rendering a list.
5.2 Create the ViewModel
For a straightforward example, use @Observable with a modern Swift toolchain. The @Observable macro is available in the Observation framework and works well with SwiftUI on supported deployment targets.
import Foundation
import Observation
@MainActor
@Observable
final class TaskListViewModel {
var tasks: [TaskItem] = []
var newTaskTitle = ""
var remainingCount: Int {
tasks.filter { !$0.isCompleted }.count
}
var canAddTask: Bool {
!newTaskTitle.trimmingCharacters(
in: .whitespacesAndNewlines
).isEmpty
}
func addTask() {
let title = newTaskTitle.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard !title.isEmpty else { return }
tasks.append(TaskItem(title: title))
newTaskTitle = ""
}
func toggleTask(_ task: TaskItem) {
guard let index = tasks.firstIndex(
where: { $0.id == task.id }
) else { return }
tasks[index].isCompleted.toggle()
}
func deleteTasks(at offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
}
}
Important details:
@Observable makes changes to tracked properties observable by compatible SwiftUI views.
@MainActor isolates the ViewModel to the main actor, which is appropriate for this UI-facing state.
remainingCount is a derived value; it does not need to be stored separately.
canAddTask keeps input validation logic out of the View.
- The ViewModel exposes methods for actions rather than requiring the View to edit the task array directly.
5.3 Create the SwiftUI View
import SwiftUI
struct TaskListView: View {
@State private var viewModel = TaskListViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 16) {
HStack {
TextField(
"Enter a task",
text: $viewModel.newTaskTitle
)
.textFieldStyle(.roundedBorder)
Button("Add") {
viewModel.addTask()
}
.disabled(!viewModel.canAddTask)
}
Text(
"Remaining tasks: \(viewModel.remainingCount)"
)
.font(.subheadline)
List {
ForEach(viewModel.tasks) { task in
HStack {
Button {
viewModel.toggleTask(task)
} label: {
Image(
systemName: task.isCompleted
? "checkmark.circle.fill"
: "circle"
)
}
.buttonStyle(.plain)
Text(task.title)
.strikethrough(task.isCompleted)
}
}
.onDelete(perform: viewModel.deleteTasks)
}
}
.padding()
.navigationTitle("My Tasks")
}
}
}
This view displays the task input, add button, remaining-task count, and task list. It calls ViewModel methods when the user interacts with the interface. The ViewModel updates state, and SwiftUI redraws the relevant parts of the view.
5.4 Understanding SwiftUI Property Wrappers
| Property Wrapper |
Typical Purpose |
Usage Notes |
@State |
Owns local state or an observable reference |
Suitable for owning an @Observable ViewModel in a SwiftUI view. |
@Observable |
Makes tracked properties observable |
Applied to a class, not a property. |
@Bindable |
Creates bindings to properties of an observable object |
Useful when a child view needs a binding such as a text field binding. |
@ObservedObject |
Observes an ObservableObject |
Common in projects using the Combine-based observation pattern. |
@StateObject |
Owns an ObservableObject reference |
Commonly used when a SwiftUI view creates and manages the lifetime of that object. |
@EnvironmentObject |
Reads a shared ObservableObject from the environment |
Useful for shared app state when appropriate; avoid making all dependencies global. |
Do not mix the Observation framework and the older Combine-based approach without understanding their different requirements. For a legacy ViewModel that conforms to ObservableObject, use the matching property wrappers and @Published properties.
5.5 Alternative: Combine-Based SwiftUI ViewModel
import Foundation
import Combine
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var name = "Guest"
func updateName(_ value: String) {
name = value.trimmingCharacters(
in: .whitespacesAndNewlines
)
}
}
A SwiftUI view can own this older-style ViewModel using @StateObject:
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
VStack {
Text("Hello, \(viewModel.name)")
Button("Set Name") {
viewModel.updateName("Alex")
}
}
}
}
Use one observation strategy consistently for each ViewModel. The Observation framework is a modern option, while ObservableObject remains useful in existing projects and codebases.
6. Implementing MVVM with UIKit
UIKit does not automatically observe arbitrary ViewModel properties. A UIKit screen needs an explicit mechanism to communicate state changes to its view controller. Common options include Combine publishers, closures, delegates, or carefully designed callbacks.
6.1 Create a UIKit Model
import Foundation
struct Product {
let id: Int
let name: String
let price: Double
}
6.2 Create a ViewModel with Combine
import Foundation
import Combine
@MainActor
final class ProductListViewModel {
@Published private(set) var products: [Product] = []
var onProductsChanged: (([Product]) -> Void)?
func loadProducts() {
products = [
Product(id: 1, name: "Keyboard", price: 1499),
Product(id: 2, name: "Mouse", price: 799),
Product(id: 3, name: "Headphones", price: 2499)
]
onProductsChanged?(products)
}
func formattedPrice(for product: Product) -> String {
product.price.formatted(
.currency(code: "INR")
)
}
}
This example exposes product state through @Published and also uses a closure to demonstrate a simple UIKit callback. In a production app, choose a single clear observation mechanism where possible. You can instead subscribe to the published property with Combine, as shown below.
6.3 Connect the ViewModel to a UIViewController
import UIKit
import Combine
@MainActor
final class ProductListViewController: UIViewController {
private let viewModel = ProductListViewModel()
private var cancellables = Set<AnyCancellable>()
private let tableView = UITableView(
frame: .zero,
style: .plain
)
private var products: [Product] = []
override func viewDidLoad() {
super.viewDidLoad()
title = "Products"
view.backgroundColor = .systemBackground
configureTableView()
bindViewModel()
viewModel.loadProducts()
}
private func configureTableView() {
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.topAnchor
),
tableView.leadingAnchor.constraint(
equalTo: view.leadingAnchor
),
tableView.trailingAnchor.constraint(
equalTo: view.trailingAnchor
),
tableView.bottomAnchor.constraint(
equalTo: view.bottomAnchor
)
])
tableView.dataSource = self
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: "ProductCell"
)
}
private func bindViewModel() {
viewModel.$products
.receive(on: DispatchQueue.main)
.sink { [weak self] products in
self?.products = products
self?.tableView.reloadData()
}
.store(in: &cancellables)
}
}
extension ProductListViewController: UITableViewDataSource {
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
products.count
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "ProductCell",
for: indexPath
)
let product = products[indexPath.row]
var content = cell.defaultContentConfiguration()
content.text = product.name
content.secondaryText = viewModel.formattedPrice(
for: product
)
cell.contentConfiguration = content
return cell
}
}
How this works: the controller configures the table view, subscribes to the ViewModel's published product array, and reloads the table when the array changes. The ViewModel does not need to know that a UITableView exists.
6.4 Alternative: Closure-Based Binding
Closures can be useful for simple one-screen examples or when the ViewModel exposes a small number of events. If you use a closure, capture the view controller weakly to avoid a retain cycle.
viewModel.onProductsChanged = { [weak self] products in
guard let self else { return }
self.products = products
self.tableView.reloadData()
}
viewModel.loadProducts()
Do not bind the same state through both this closure and the Combine subscription unless there is a specific reason. Duplicate bindings can cause repeated UI updates and confusing behavior.
7. SwiftUI MVVM vs. UIKit MVVM
| Area |
SwiftUI |
UIKit |
| UI creation |
Declarative views |
View controllers and views |
| State updates |
Observation or Combine integration |
Explicit binding through Combine, closures, delegates, or callbacks |
| UI refresh |
Driven by state changes |
Often requires table reloads, collection updates, or view configuration |
| ViewModel ownership |
Typically @State for @Observable or @StateObject for ObservableObject |
Often a stored property or dependency injected by a coordinator or parent |
| Data binding |
Built-in binding syntax such as $viewModel.name |
Usually explicit event handlers and observation |
| Testing |
ViewModel logic can be tested separately from the view |
ViewModel logic can be tested separately from the view controller |
The architectural principle remains the same across both frameworks. The biggest difference is how each framework creates the interface and observes changes in state.
8. Adding a Service Layer
In a real application, the ViewModel should not usually contain all networking or persistence code. A service or repository can provide data and keep external dependencies separate from presentation logic.
import Foundation
protocol UserService {
func fetchUsers() async throws -> [User]
}
struct APIUserService: UserService {
func fetchUsers() async throws -> [User] {
let url = URL(
string: "https://jsonplaceholder.typicode.com/users"
)!
let (data, response) = try await URLSession.shared.data(
from: url
)
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([User].self, from: data)
}
}
The URL above is a sample public API endpoint. In a real project, define endpoint configuration carefully, handle authentication when needed, and avoid force-unwrapping URLs derived from external input.
8.1 ViewModel with Loading and Error States
import Foundation
import Observation
@MainActor
@Observable
final class UserListViewModel {
private let service: UserService
private(set) var users: [User] = []
private(set) var isLoading = false
var errorMessage: String?
init(service: UserService) {
self.service = service
}
func loadUsers() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
users = try await service.fetchUsers()
} catch {
errorMessage = "Unable to load users. Please try again."
}
}
}
Dependency injection makes the ViewModel easier to test because the service can be replaced with a fake implementation. The ViewModel also exposes loading and error state instead of making the view infer those states from the network request.
8.2 SwiftUI View for Asynchronous User Data
import SwiftUI
struct UserListScreen: View {
@State private var viewModel: UserListViewModel
init(service: UserService = APIUserService()) {
_viewModel = State(
initialValue: UserListViewModel(service: service)
)
}
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading users...")
} else if let message = viewModel.errorMessage {
VStack(spacing: 12) {
Text(message)
Button("Retry") {
Task {
await viewModel.loadUsers()
}
}
}
} else {
List(viewModel.users) { user in
VStack(alignment: .leading) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Users")
.task {
if viewModel.users.isEmpty {
await viewModel.loadUsers()
}
}
}
}
}
This screen demonstrates the common loading, success, and error states. In larger applications, consider representing screen state with an enum such as idle, loading, loaded, and failed when that makes invalid combinations of state harder to express.
9. Unit Testing a ViewModel
MVVM makes it possible to test most presentation logic without launching the UI. A test can inject a fake service and verify that the ViewModel updates its state as expected.
import XCTest
@testable import YourAppModule
struct FakeUserService: UserService {
let result: [User]
func fetchUsers() async throws -> [User] {
result
}
}
@MainActor
final class UserListViewModelTests: XCTestCase {
func testLoadUsersStoresReturnedUsers() async {
let expected = [
User(id: 1, name: "Sam", email: "[email protected]")
]
let service = FakeUserService(result: expected)
let viewModel = UserListViewModel(service: service)
await viewModel.loadUsers()
XCTAssertEqual(viewModel.users.count, 1)
XCTAssertEqual(viewModel.users.first?.name, "Sam")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
}
Replace YourAppModule with the actual module name in your project. Depending on your Swift version and test configuration, you may need to adapt actor isolation or test setup.
Useful Test Cases
- Verify that valid input creates a model or triggers the intended action.
- Verify that empty or invalid input is rejected.
- Verify that successful service responses update the ViewModel's state.
- Verify that failed requests produce an appropriate error state.
- Verify that loading state returns to false after success or failure.
- Verify that retrying a failed operation can succeed.
10. Best Practices for MVVM in iOS
- Keep Views focused: avoid putting networking, complex validation, and data transformation directly into the UI.
- Keep ViewModels UI-framework independent: avoid importing UIKit or SwiftUI into ViewModels unless there is a clear, justified need.
- Use dependency injection: pass services or repositories into ViewModels rather than hard-coding concrete dependencies.
- Keep state coherent: represent loading, success, empty, and failure states clearly.
- Use main-actor isolation for UI-facing state: avoid updating UI-observed state from an inappropriate execution context.
- Avoid oversized ViewModels: separate responsibilities when a ViewModel begins handling many unrelated features.
- Prefer meaningful names: use names such as
LoginViewModel and OrderHistoryViewModel.
- Test business and presentation logic: use fake services to test successful and failing scenarios.
- Avoid unnecessary abstractions: introduce protocols and repositories when they improve flexibility, clarity, or testability.
- Do not confuse MVVM with a strict rulebook: adapt the pattern to the scale and needs of the application.
11. Common Mistakes and Troubleshooting
| Problem |
Likely Cause |
Solution |
| SwiftUI does not refresh |
The property is not observed, or the wrong observation pattern is used |
Check whether the model uses @Observable or ObservableObject and use the matching ownership and observation mechanism. |
| UIKit list remains unchanged |
The controller does not respond to ViewModel changes |
Bind a publisher, closure, or delegate and update the table or collection view. |
| ViewModel is difficult to test |
It creates network services internally or relies on global state |
Inject dependencies through its initializer. |
| Repeated network requests |
Loading is triggered repeatedly by lifecycle or rendering events |
Choose a clear request trigger, manage task lifetime, and consider caching or request deduplication. |
| Memory leaks in UIKit |
A closure or Combine subscription strongly retains its owner |
Use weak captures where appropriate and manage cancellables for the correct lifetime. |
| UI state changes off the main actor |
Concurrency and actor isolation are not handled correctly |
Use appropriate actor isolation and keep UI-facing state updates on the main actor. |
| ViewModel becomes too large |
Networking, formatting, navigation, and unrelated features are mixed together |
Extract focused services, repositories, or child ViewModels where this reduces complexity. |
12. Practical Project: Product Catalog Application
Build a small product catalog using MVVM. Implement the interface in either SwiftUI or UIKit while reusing the same Model and service layer where practical.
Suggested Features
- Display a list of products with names and prices.
- Fetch products from an API or a local mock service.
- Show a loading indicator while data is being retrieved.
- Display an empty state when no products are available.
- Show an understandable error message when loading fails.
- Provide a retry button.
- Add search and category filtering through ViewModel methods.
- Write unit tests for loading, filtering, and error handling.
Suggested Folder Structure
ProductCatalog/
├── Models/
│ └── Product.swift
├── Services/
│ ├── ProductService.swift
│ └── APIProductService.swift
├── ViewModels/
│ └── ProductListViewModel.swift
├── Views/
│ ├── SwiftUI/
│ │ └── ProductListView.swift
│ └── UIKit/
│ └── ProductListViewController.swift
└── Tests/
└── ProductListViewModelTests.swift
This structure is a starting point, not a mandatory layout. Organize files according to the size of the project and the conventions of the team.
13. Practice Exercises
- Create a login screen with
LoginViewModel and validate email and password input.
- Build a notes list where the ViewModel adds, edits, deletes, and filters notes.
- Implement the same product list using SwiftUI and UIKit while sharing the data model.
- Add a fake service that simulates a network failure and test the retry flow.
- Refactor a view controller that contains networking and data parsing into a ViewModel and service.
- Write unit tests for loading, empty results, validation, and error states.
14. Interview Questions and Answers
Q1. What is MVVM?
MVVM is an architectural pattern that separates data and domain concepts, UI presentation, and presentation logic into Model, View, and ViewModel components.
Q2. What is the responsibility of a ViewModel?
A ViewModel prepares state for the View, processes UI actions, coordinates services, and exposes results or errors in a form the View can present.
Q3. Can MVVM be used with UIKit?
Yes. UIKit can use ViewModels with Combine, closures, delegates, or other binding techniques. The view controller observes state and updates the interface.
Q4. How does SwiftUI observe a ViewModel?
SwiftUI can observe a class using the Observation framework's @Observable macro or the Combine-based ObservableObject protocol. The correct property wrappers depend on which approach is used and who owns the object.
Q5. What is dependency injection?
Dependency injection means providing an object with the services or collaborators it needs, often through an initializer. This makes components easier to replace, configure, and test.
Q6. Why should networking usually be outside the View?
Separating networking from presentation keeps UI code simpler, supports reuse, centralizes request handling, and makes the application's behavior easier to test.
Q7. What is the difference between a Model and a ViewModel?
A Model represents domain data or concepts. A ViewModel prepares state and actions for the UI and coordinates work needed to display or modify that data.
Q8. How do you test a ViewModel?
Instantiate the ViewModel in a unit test, inject a fake or mock service, call its methods, and assert that its state and outputs match expectations.
Q9. Does MVVM require a repository?
No. A repository is an optional supporting abstraction. It is useful when it isolates data sources or improves testability, but it is not mandatory for every small screen.
Q10. What is a common MVVM mistake?
A common mistake is moving all logic into one very large ViewModel. The goal is separation of responsibilities, not simply relocating a massive view controller into another class.
15. Quick Revision Checklist
- Understand the Model, View, and ViewModel roles.
- Keep presentation code separate from data access.
- Use the appropriate SwiftUI observation strategy.
- Bind UIKit views to ViewModel changes explicitly.
- Manage loading, success, empty, and failure states.
- Inject services to improve flexibility and testing.
- Test ViewModel behavior independently of the UI.
- Handle asynchronous work and actor isolation correctly.
- Keep each component focused on a clear responsibility.
16. Conclusion
MVVM is a practical architecture for organizing iOS applications built with SwiftUI and UIKit. The Model represents the data, the View presents the interface, and the ViewModel coordinates presentation state and user actions. SwiftUI provides declarative state-driven updates, while UIKit usually needs explicit bindings through Combine, closures, or delegates.
By separating UI code from data access, injecting dependencies, handling asynchronous states carefully, and testing ViewModels independently, developers can build iOS applications that are easier to maintain and extend.
Additional Learning Resources